What it would take to turn the best-running iPod game into portable source, how many functions that is, and which route gets there without a compiler nobody has.
This is a moderate, well-bounded project — much smaller than a typical console decomp. The executable is 227 KB, of which only ~92 KB is code, and the code is C++ (armcc's C++ runtime is linked in — vtables, Pure virtual fn called) compiled by ARM's own armcc with 16.16 fixed-point arithmetic throughout. Ghidra decompiles every function cleanly (415 of 415, no failures) on a first, untyped pass. Everything the game asks of the outside world goes through 65 numbered import thunks whose semantics your reversing/asyncfileio-abi.md already documents and eapp-loader already implements in Rust.
Language: C++17 throughout. The original is C++, so hand decompilation recovers the game's classes directly; every target toolchain (Apple clang, MSVC, NDK, MinGW, devkitARM for 3DS) has full C++17. The emitter writes .cpp too, so there is no C/C++ boundary in the project. Runtime and generated layers use C++ as a better C (no exceptions, no RTTI, no hot-path allocation); the host library, platform layer, and hand-written code use idiomatic modern C++. The full code-quality rules — naming, commenting, structure, warnings-as-errors, sanitisers — are the first section of PLAN.md and bind every step of the work.
A byte-matching build is not realistic and not necessary: the compiler is an ARM ADS/RVCT release you cannot license, and you already own something better than a matching build for verification — a deterministic emulator with scripted input that can emit the exact sequence of framework calls a session makes. That is the oracle the port should be tested against.
| Region | Address range | Size | What it is |
|---|---|---|---|
| eApp header + descriptors | 0x18000000–0x18000064 | 100 B | magic eapp, entry vector table at +0x14 (entry 0x180188c0, frame hook 0x1801891c), six framework descriptors with MD5 interface hashes |
| Import thunks | 0x18000064–0x18000a3c | 2.5 KB | 277 ldr pc,[pc,#imm] stubs: OpenGLES 179, Audio 61, AsyncFileIO 17, miscTBD 15, Settings 3, InputEvents 2 |
| ARM C runtime | 0x18000a3c–0x18002c28 | 8 KB | 53 functions: heap (with the $$$$ a^n + b^n = c^n | n>2 $$$$ sentinel), memcpy 0x18001124, memset 0x18001190, __rt_udiv 0x18001468, 64-bit divide 0x18000ba4, signal/__rt_raise messages, semihosting svc 0x123456 |
| Game code | 0x18002c28–0x18018e40 | 88 KB | 304 functions, 20 942 instructions. 100% ARM state, no blx, no VFP, no softfloat calls |
| Read-only data | 0x18019000–0x18030000 | 92 KB | runtime messages, asset filenames (c%02dbank/%01d.wav, jdmgp.sav), and ~70 KB of bitmaps encoded as 16-level ASCII art ( #%'(*-.2357<@DG ramp) — sprites/glyphs baked as strings |
| Data / BSS start | 0x18030000–0x18037a1c | 31 KB | tables, globals (Ghidra sees 912 distinct globals), input flags word at 0x18037a0c |
The runtime strings Pure virtual fn called, Redirect: can't open:, Out of heap memory, Heap memory corrupted, C++ library exception and the heap sentinel above are ARM Ltd's C library as shipped with ARM Developer Suite 1.2 / RealView (RVCT 2.x) — not GCC. Matching that output requires that exact armcc build (plus its exact options), which is not obtainable legitimately. decomp.me hosts a handful of armcc/RVCT versions; pinning the version from the library's byte patterns would be the first task if anyone ever wanted a matching build. I would not spend the effort: see the verification section.
0x1800a080, 2 092 insns, the course renderer) is all x * 0x10000, + 0x2800000, 0x1400000 — 16.16 screen coordinates handed to glVertexAttribPointer with type GL_FIXED. Ghidra's output contains zero floats. The only IEEE values are constants passed to glClearColor/ortho. No floating-point semantics to get wrong.0x180066a4–0x18006d3c and the callback plumbing near the entry) and 25 switch jump tables. Straightforward to recover.DAT_1800b040 + -0x370c style) with signed offsets — i.e. a few big global structs. Naming those structs is the bulk of the typing work and pays off across every function.b . self-branches) — release build with asserts compiled to spin. These become assert(); they are also free breadcrumbs for invariants.| Bucket | Functions | Instructions | What happens to it in a port |
|---|---|---|---|
| Import thunks | 58 | 58 | Vanish — become direct calls into the host library |
| ARM C runtime | 53 | 1 983 | Replace with libc. Only 32 are called from game code and they reduce to ~10 semantics: memcpy, memset, strlen/strcpy family, udiv/sdiv/div64, malloc/free, printf-ish, and the init/exit shims |
| Game code | 304 | 20 942 | Decompile. 70 are under 10 insns, 96 are 10–25, 71 are 25–50, 68 are 50–100, 35 are 100–200, 12 are 200–500, 5 are over 500 |
| Total (Ghidra) | 415 | 22 983 |
Dynamic coverage, measured with the --callgraph-dump flag I added to play this session: five scripted sessions covering boot, asset load, the title, name entry and its menus reached 191 of 304 game functions (8 413 insns). The unreached 113 include all twelve largest functions — the course renderer, ball physics, hole/score flow and the pause menu — i.e. roughly 60% of the instructions are gameplay code that only a played hole exercises.
The emulator cannot currently get past name entry, which is what blocked a gameplay coverage number. Confirming the name (the ✓ cell) issues AsyncFileIO #12 with a 0xc0debabe-tagged request and #14, both answered with zero today, and the game stays on the screen. That is the "why is no save ever written" open item in the ABI notes, now with a reproducible trigger: select, scroll to ✓, select. Separately, wheel acceleration makes scripted bursts land nondeterministically; the script format wants a "move N cells" primitive.
| Entry | Insns | Callees | Reading |
|---|---|---|---|
0x1800a080 | 2 092 | 33 | course/scene draw: sets clear colour, pipeline, uniforms, builds fixed-point quad lists, texture binds |
0x18002c28 | 1 354 | 25 | first game function after the runtime — likely the main state machine |
0x1800ecd8 | 655 | 49 | highest fan-out: init / asset loading orchestration |
0x1800d1c4 | 612 | 7 | few callees, long body — physics or collision arithmetic |
0x180154bc | 517 | 5 | same shape |
Static scan of every B/BL into the thunk arrays plus literal-pool references, reproducing §18.1's hand count exactly. 65 distinct ordinals, of which 44 were observed live in the probe sessions.
| Framework | Used / published | Ordinals (call sites) |
|---|---|---|
| OpenGLES | 20 / 179 | #4 glBindTexture(9) #12 glClear(3) #13 glClearColor(4) #19 glCompressedTexImage2D #21 glCopyTexImage2D #36(8) #37 glDrawArrays(4) #40 glEnableVertexAttribArray(8) #53 glGetError(24) #84 glPixelStorei #99 glTexImage2D #101 glTexParameterf #125 glUniformMatrix4fv #137 glVertexAttribPointer(8) #157 swap #158 private enable #159 pipeline select(5) #165 loadIdentity(2) #167 ortho #175 multMatrix(3) |
| Audio | 25 / 61 | sound-engine handle table #0 #1 #2 #7–#15 #17 #18 #23; music player #40 #42 #43 #45 #48(repeat) #51–#53 #55 #56 |
| miscTBD | 10 / 15 | #0 alloc #5 #6 #7 level trio #9 µs clock(3) #10=1000 #11=0 #12 wall clock #13 battery #14 resolve resource name |
| AsyncFileIO | 8 / 17 | #0 #1 #2 #3 #4 #12 #14 #16 — open/read/close with completion callbacks; #12/#14 are the un-modelled save path |
| InputEvents | 1 / 2 | #0 poll (wheel position + five button bits) |
| Settings | 1 / 3 | #0 "Language" (word index) — Mini Golf does not ask for TimeFormat |
Across all 18 titles the surface is 161 ordinals (covscan), and 158 are already implemented in eapp-loader. So the library for all the games is about 2.5× the Mini Golf library, not 18×, and the Rust stubs are a working reference implementation to port from.
m0.m4a–m2.m4a go through the iPod's own player (Audio #40 + repeat mode). SDL has no AAC decoder. Options: AudioToolbox on Apple platforms, MediaCodec on Android, fdk-aac/ffmpeg on desktop, or a one-time transcode to Ogg/WAV at install (3DS would need this). SFX are plain .wav in c00bank/ — trivial.#159 selects one of fifty fixed pipelines, #148 is the modulate colour, vertices are 16.16 fixed, GL_LUMINANCE_ALPHA textures, bilinear sampling always on, no blend disable. Implementing this as a textured-quad renderer over any backend is far easier than pretending to be a GL driver, and it is the only design that also fits the 3DS.REQ_CALLBACK/REQ_CONTEXT offsets, the arg0 == arg1 + 0x128 assertion). The port can satisfy this synchronously and fire the callback at the next frame pump, exactly as play does.Walk the 415 Ghidra functions, emit one C++ function per ARM function with a 16-register CPU struct and a flat memory array; thunk calls become direct calls into the host library. You already have the decoder (arm7tdmi::disasm) and know every quirk the titles hit. Produces a mechanical but correct-by-construction port of all 18 titles at once, in weeks. It is also the single best verification harness for Route B.
Cost: ~1–2 weeks to first frame with this codebase. Output: not "source", but a portable binary.
304 functions. 237 of them are under 50 instructions and mostly read as finished C++ out of Ghidra once the globals and classes are typed. The 52 over 100 instructions are the real work — maybe two to five days each for the five giants. Realistic solo total, focused: 6–10 weeks to a playable port, plus whatever time the library takes. With aggressive LLM assistance on the small functions, the tail shrinks a lot; the large ones still want a human reading the fixed-point math.
Cost: months. Output: maintainable source you can actually port.
Ship Route A so the game runs natively on macOS from day one, then swap recompiled functions for hand-written C++ one at a time. Every swap is verified by the oracle below. The host library is shared by both halves, so it is never throwaway work, and the port is always playable — you never have the "nothing works until everything works" cliff that kills decomp projects.
Equivalence here is behavioural, and you can measure it precisely because every side effect the game has goes through the 65 ordinals. Define a session as (script of input events) → (ordered log of framework calls with arguments, plus framebuffer hashes at chosen frames). The emulator already produces that log; the port produces the same log from the same script. Diff them. When a hand-decompiled function replaces a recompiled one and the log is unchanged across the test scripts, it is correct for those paths. This is strictly stronger than "it looks right" and it is cheap — a name-entry session is 30 M instructions, under a second natively.
It also answers the "how do I know I'm done" question: the call-graph dump tells you which functions a script reached, so test scripts can be grown until every one of the 304 functions has been executed under diff.
Abstract at the level the games already abstract at — the eight framework ABIs — not at GL. The games never see a GL context; they see 179 numbered entries, and the interesting ones are not standard GL anyway. Below the ABI, a very small platform layer is all any new target has to write.
Backend choice: start with SDL3 — it gives you the window, audio device, input and file paths on macOS, iOS, Android, Windows and Linux from one codebase, and its GPU API or plain OpenGL handles a textured-quad renderer with no effort. Keep an SDL2 backend only if you need a platform SDL3 has dropped. Vulkan/MoltenVK buys nothing for 320×240 quad drawing; treat it as an optional backend, not the foundation. The 3DS has neither SDL3 nor GL, which is exactly why the backend boundary is "draw these quads", not "here is a GL context". Write the reference backend first as a software rasteriser — it is a port of the one in lib.rs and doubles as the bit-exact comparison target.
analyzeHeadless … -processor ARM:LE:32:v4t -loader-baseAddr 0x18000000 -postScript DumpFuncs.java → functions.tsv (415 rows: entry, size, callers, callees, insns) and decomp.c (21 869 lines, all 415 functions). Same recipe works for the other 17 titles. The running GUI instance on port 8089 has no project open; the headless project is at scratchpad/gproj/minigolf.play --callgraph-dump=FILE — new this session (uncommitted, in play.rs alongside your other uncommitted changes). Writes every taken branch edge; intersect the targets with functions.tsv for live coverage per script.covscan (static ordinal coverage), dis --base=0x18000000 (static reader for game code), eapp-inspect (header/descriptors) — all already in tree./opt/devkitpro/devkitARM has arm-none-eabi-objdump, which was enough for the layout scan; it cannot reproduce armcc output, so it is a reader, not a builder, for this project.AsyncFileIO #12/#14 (the 0xc0debabe request) so the ✓ confirm proceeds, and add a "move N cells" script primitive. Then record a gameplay script and get the coverage number for the remaining 113 functions. Everything downstream wants that script.ipod_eapp.h generated from the descriptor tables plus the names in opengles-names.json/framework-functions.json. Both routes and the library compile against it.arm7tdmi::disasm and Ghidra's function list; link against the runtime; diff call logs against the emulator.All numbers measured on Games_RO/88888/Executables/Minigolf_1_1_2563296.bin (227 868 bytes) with Ghidra 12.1.3 default ARM analysis; the function count will move a little once thunks are marked as externals and the 41 caller-less entries are confirmed as callbacks rather than dead code.